From 027972a20f94492274b36b8d9277d3c36d4d2f9b Mon Sep 17 00:00:00 2001 From: Stanislav Malyshev Date: Tue, 26 Jan 2016 13:18:27 -0800 Subject: [PATCH] Include completion search into SearchEngine By default it still uses PrefixSearch and supports PrefixSearchBackend but it can be deprecated and phased out and SearchEngine extensions used instead. New APIs: - SearchEngine public function defaultPrefixSearch( $search ); public function completionSearch( $search ); public function completionSearchWithVariants( $search ); Search engines should override: protected function completionSearchBackend( $search ); Bug: T121430 Change-Id: Ie78649591dff94d21b72fad8e4e5eab010a461df --- autoload.php | 2 + includes/PrefixSearch.php | 12 +- includes/api/ApiOpenSearch.php | 9 +- includes/api/ApiQueryPrefixSearch.php | 7 +- includes/search/SearchEngine.php | 204 +++++++++++ includes/search/SearchSuggestion.php | 187 ++++++++++ includes/search/SearchSuggestionSet.php | 213 +++++++++++ includes/specialpage/SpecialPage.php | 23 ++ includes/specials/SpecialAllPages.php | 10 +- .../specials/SpecialChangeContentModel.php | 10 +- .../specials/SpecialFileDuplicateSearch.php | 6 +- includes/specials/SpecialMovepage.php | 10 +- includes/specials/SpecialPageLanguage.php | 10 +- includes/specials/SpecialPrefixindex.php | 10 +- .../specials/SpecialRecentchangeslinked.php | 10 +- includes/specials/SpecialUndelete.php | 10 +- includes/specials/SpecialWhatlinkshere.php | 10 +- .../search/SearchEnginePrefixTest.php | 334 ++++++++++++++++++ .../search/SearchSuggestionSetTest.php | 104 ++++++ 19 files changed, 1099 insertions(+), 82 deletions(-) create mode 100644 includes/search/SearchSuggestion.php create mode 100644 includes/search/SearchSuggestionSet.php create mode 100644 tests/phpunit/includes/search/SearchEnginePrefixTest.php create mode 100644 tests/phpunit/includes/search/SearchSuggestionSetTest.php diff --git a/autoload.php b/autoload.php index b055574aea..03666dc176 100644 --- a/autoload.php +++ b/autoload.php @@ -1120,6 +1120,8 @@ $wgAutoloadLocalClasses = array( 'SearchResult' => __DIR__ . '/includes/search/SearchResult.php', 'SearchResultSet' => __DIR__ . '/includes/search/SearchResultSet.php', 'SearchSqlite' => __DIR__ . '/includes/search/SearchSqlite.php', + 'SearchSuggestion' => __DIR__ . '/includes/search/SearchSuggestion.php', + 'SearchSuggestionSet' => __DIR__ . '/includes/search/SearchSuggestionSet.php', 'SearchUpdate' => __DIR__ . '/includes/deferred/SearchUpdate.php', 'SectionProfileCallback' => __DIR__ . '/includes/profiler/SectionProfiler.php', 'SectionProfiler' => __DIR__ . '/includes/profiler/SectionProfiler.php', diff --git a/includes/PrefixSearch.php b/includes/PrefixSearch.php index c6f187d2b7..5f36cf507d 100644 --- a/includes/PrefixSearch.php +++ b/includes/PrefixSearch.php @@ -23,6 +23,7 @@ /** * Handles searching prefixes of titles and finding any page * names that match. Used largely by the OpenSearch implementation. + * @deprecated Since 1.27, Use SearchEngine::prefixSearchSubpages or SearchEngine::completionSearch * * @ingroup Search */ @@ -259,14 +260,17 @@ abstract class PrefixSearch { * @param int $offset Number of items to skip * @return array Array of Title objects */ - protected function defaultSearchBackend( $namespaces, $search, $limit, $offset ) { + public function defaultSearchBackend( $namespaces, $search, $limit, $offset ) { $ns = array_shift( $namespaces ); // support only one namespace - if ( in_array( NS_MAIN, $namespaces ) ) { + if ( is_null( $ns ) || in_array( NS_MAIN, $namespaces ) ) { $ns = NS_MAIN; // if searching on many always default to main } - $t = Title::newFromText( $search, $ns ); + if ( $ns == NS_SPECIAL ) { + return $this->specialSearch( $search, $limit, $offset ); + } + $t = Title::newFromText( $search, $ns ); $prefix = $t ? $t->getDBkey() : ''; $dbr = wfGetDB( DB_SLAVE ); $res = $dbr->select( 'page', @@ -318,6 +322,7 @@ abstract class PrefixSearch { /** * Performs prefix search, returning Title objects + * @deprecated Since 1.27, Use SearchEngine::prefixSearchSubpages or SearchEngine::completionSearch * @ingroup Search */ class TitlePrefixSearch extends PrefixSearch { @@ -337,6 +342,7 @@ class TitlePrefixSearch extends PrefixSearch { /** * Performs prefix search, returning strings + * @deprecated Since 1.27, Use SearchEngine::prefixSearchSubpages or SearchEngine::completionSearch * @ingroup Search */ class StringPrefixSearch extends PrefixSearch { diff --git a/includes/api/ApiOpenSearch.php b/includes/api/ApiOpenSearch.php index 5ce43ccead..ff5707e68d 100644 --- a/includes/api/ApiOpenSearch.php +++ b/includes/api/ApiOpenSearch.php @@ -123,9 +123,12 @@ class ApiOpenSearch extends ApiBase { * @param array &$results Put results here. Keys have to be integers. */ protected function search( $search, $limit, $namespaces, $resolveRedir, &$results ) { - // Find matching titles as Title objects - $searcher = new TitlePrefixSearch; - $titles = $searcher->searchWithVariants( $search, $limit, $namespaces ); + + $searchEngine = SearchEngine::create(); + $searchEngine->setLimitOffset( $limit ); + $searchEngine->setNamespaces( $namespaces ); + $titles = $searchEngine->extractTitles( $searchEngine->completionSearchWithVariants( $search ) ); + if ( !$titles ) { return; } diff --git a/includes/api/ApiQueryPrefixSearch.php b/includes/api/ApiQueryPrefixSearch.php index 25ff07c3f6..1dac740262 100644 --- a/includes/api/ApiQueryPrefixSearch.php +++ b/includes/api/ApiQueryPrefixSearch.php @@ -45,8 +45,11 @@ class ApiQueryPrefixSearch extends ApiQueryGeneratorBase { $namespaces = $params['namespace']; $offset = $params['offset']; - $searcher = new TitlePrefixSearch; - $titles = $searcher->searchWithVariants( $search, $limit + 1, $namespaces, $offset ); + $searchEngine = SearchEngine::create(); + $searchEngine->setLimitOffset( $limit + 1, $offset ); + $searchEngine->setNamespaces( $namespaces ); + $titles = $searchEngine->extractTitles( $searchEngine->completionSearchWithVariants( $search ) ); + if ( $resultPageSet ) { $resultPageSet->setRedirectMergePolicy( function( array $current, array $new ) { if ( !isset( $current['index'] ) || $new['index'] < $current['index'] ) { diff --git a/includes/search/SearchEngine.php b/includes/search/SearchEngine.php index 3c8d56ea48..81b850a27b 100644 --- a/includes/search/SearchEngine.php +++ b/includes/search/SearchEngine.php @@ -296,6 +296,15 @@ class SearchEngine { * @param int[]|null $namespaces */ function setNamespaces( $namespaces ) { + if ( $namespaces ) { + // Filter namespaces to only keep valid ones + $validNs = $this->searchableNamespaces(); + $namespaces = array_filter( $namespaces, function( $ns ) use( $validNs ) { + return $ns < 0 || isset( $validNs[$ns] ); + } ); + } else { + $namespaces = array(); + } $this->namespaces = $namespaces; } @@ -570,6 +579,201 @@ class SearchEngine { public function textAlreadyUpdatedForIndex() { return false; } + + /** + * Makes search simple string if it was namespaced. + * Sets namespaces of the search to namespaces extracted from string. + * @param string $search + * @return $string Simplified search string + */ + protected function normalizeNamespaces( $search ) { + // Find a Title which is not an interwiki and is in NS_MAIN + $title = Title::newFromText( $search ); + $ns = $this->namespaces; + if ( $title && !$title->isExternal() ) { + $ns = array( $title->getNamespace() ); + $search = $title->getText(); + if ( $ns[0] == NS_MAIN ) { + $ns = $this->namespaces; // no explicit prefix, use default namespaces + Hooks::run( 'PrefixSearchExtractNamespace', array( &$ns, &$search ) ); + } + } else { + $title = Title::newFromText( $search . 'Dummy' ); + if ( $title && $title->getText() == 'Dummy' + && $title->getNamespace() != NS_MAIN + && !$title->isExternal() ) + { + $ns = array( $title->getNamespace() ); + $search = ''; + } else { + Hooks::run( 'PrefixSearchExtractNamespace', array( &$ns, &$search ) ); + } + } + + $ns = array_map( function( $space ) { + return $space == NS_MEDIA ? NS_FILE : $space; + }, $ns ); + + $this->setNamespaces( $ns ); + return $search; + } + + /** + * Perform a completion search. + * Does not resolve namespaces and does not check variants. + * Search engine implementations may want to override this function. + * @param string $search + * @return SearchSuggestionSet + */ + protected function completionSearchBackend( $search ) { + $results = array(); + + $search = trim( $search ); + + if ( !in_array( NS_SPECIAL, $this->namespaces ) && // We do not run hook on Special: search + !Hooks::run( 'PrefixSearchBackend', + array( $this->namespaces, $search, $this->limit, &$results, $this->offset ) + ) ) { + // False means hook worked. + // FIXME: Yes, the API is weird. That's why it is going to be deprecated. + + return SearchSuggestionSet::fromStrings( $results ); + } else { + // Hook did not do the job, use default simple search + $results = $this->simplePrefixSearch( $search ); + return SearchSuggestionSet::fromTitles( $results ); + } + } + + /** + * Perform a completion search. + * @param string $search + * @return SearchSuggestionSet + */ + public function completionSearch( $search ) { + if ( trim( $search ) === '' ) { + return SearchSuggestionSet::emptySuggestionSet(); // Return empty result + } + $search = $this->normalizeNamespaces( $search ); + return $this->processCompletionResults( $search, $this->completionSearchBackend( $search ) ); + } + + /** + * Perform a completion search with variants. + * @param string $search + * @return SearchSuggestionSet + */ + public function completionSearchWithVariants( $search ) { + if ( trim( $search ) === '' ) { + return SearchSuggestionSet::emptySuggestionSet(); // Return empty result + } + $search = $this->normalizeNamespaces( $search ); + + $results = $this->completionSearchBackend( $search ); + $fallbackLimit = $this->limit - $results->getSize(); + if ( $fallbackLimit > 0 ) { + global $wgContLang; + + $fallbackSearches = $wgContLang->autoConvertToAllVariants( $search ); + $fallbackSearches = array_diff( array_unique( $fallbackSearches ), array( $search ) ); + + foreach ( $fallbackSearches as $fbs ) { + $this->setLimitOffset( $fallbackLimit ); + $fallbackSearchResult = $this->completionSearch( $fbs ); + $results->appendAll( $fallbackSearchResult ); + $fallbackLimit -= count( $fallbackSearchResult ); + if ( $fallbackLimit <= 0 ) { + break; + } + } + } + return $this->processCompletionResults( $search, $results ); + } + + /** + * Extract titles from completion results + * @param SearchSuggestionSet $completionResults + * @return Title[] + */ + public function extractTitles( SearchSuggestionSet $completionResults ) { + return $completionResults->map( function( SearchSuggestion $sugg ) { + return $sugg->getSuggestedTitle(); + } ); + } + + /** + * Process completion search results. + * Resolves the titles and rescores. + * @param SearchSuggestionSet $suggestions + * @return SearchSuggestionSet + */ + protected function processCompletionResults( $search, SearchSuggestionSet $suggestions ) { + if ( $suggestions->getSize() == 0 ) { + // If we don't have anything, don't bother + return $suggestions; + } + $search = trim( $search ); + // preload the titles with LinkBatch + $titles = $suggestions->map( function( SearchSuggestion $sugg ) { + return $sugg->getSuggestedTitle(); + } ); + $lb = new LinkBatch( $titles ); + $lb->setCaller( __METHOD__ ); + $lb->execute(); + + $results = $suggestions->map( function( SearchSuggestion $sugg ) { + return $sugg->getSuggestedTitle()->getPrefixedText(); + } ); + + // Rescore results with an exact title match + $rescorer = new SearchExactMatchRescorer(); + $rescoredResults = $rescorer->rescore( $search, $this->namespaces, $results, $this->limit ); + + if ( count( $rescoredResults ) > 0 ) { + $found = array_search( $rescoredResults[0], $results ); + if ( $found === false ) { + // If the first result is not in the previous array it + // means that we found a new exact match + $exactMatch = SearchSuggestion::fromTitle( 0, Title::newFromText( $rescoredResults[0] ) ); + $suggestions->prepend( $exactMatch ); + $suggestions->shrink( $this->limit ); + } else { + // if the first result is not the same we need to rescore + if ( $found > 0 ) { + $suggestions->rescore( $found ); + } + } + } + + return $suggestions; + } + + /** + * Simple prefix search for subpages. + * @param string $search + * @return Title[] + */ + public function defaultPrefixSearch( $search ) { + if ( trim( $search ) === '' ) { + return array(); + } + + $search = $this->normalizeNamespaces( $search ); + return $this->simplePrefixSearch( $search ); + } + + /** + * Call out to simple search backend. + * Defaults to TitlePrefixSearch. + * @param string $search + * @return Title[] + */ + protected function simplePrefixSearch( $search ) { + // Use default database prefix search + $backend = new TitlePrefixSearch; + return $backend->defaultSearchBackend( $this->namespaces, $search, $this->limit, $this->offset ); + } + } /** diff --git a/includes/search/SearchSuggestion.php b/includes/search/SearchSuggestion.php new file mode 100644 index 0000000000..cd9062b16f --- /dev/null +++ b/includes/search/SearchSuggestion.php @@ -0,0 +1,187 @@ +score = $score; + $this->text = $text; + if ( $suggestedTitle ) { + $this->setSuggestedTitle( $suggestedTitle ); + } + $this->suggestedTitleID = $suggestedTitleID; + } + + /** + * The suggestion text + * @return string + */ + public function getText() { + return $this->text; + } + + /** + * Set the suggestion text. + * @param string $text + * @param bool $setTitle Should we also update the title? + */ + public function setText( $text, $setTitle = true ) { + $this->text = $text; + if ( $setTitle && $text ) { + $this->setSuggestedTitle( Title::makeTitle( 0, $text ) ); + } + } + + /** + * Title object in the case this suggestion is based on a title. + * May return null if the suggestion is not a Title. + * @return Title|null + */ + public function getSuggestedTitle() { + return $this->suggestedTitle; + } + + /** + * Set the suggested title + * @param Title|null $title + */ + public function setSuggestedTitle( Title $title = null ) { + $this->suggestedTitle = $title; + if ( $title !== null ) { + $this->url = wfExpandUrl( $title->getFullURL(), PROTO_CURRENT ); + } + } + + /** + * Title ID in the case this suggestion is based on a title. + * May return null if the suggestion is not a Title. + * @return int|null + */ + public function getSuggestedTitleID() { + return $this->suggestedTitleID; + } + + /** + * Set the suggested title ID + * @param int|null $suggestedTitleID + */ + public function setSuggestedTitleID( $suggestedTitleID = null ) { + $this->suggestedTitleID = $suggestedTitleID; + } + + /** + * Suggestion score + * @return float Suggestion score + */ + public function getScore() { + return $this->score; + } + + /** + * Set the suggestion score + * @param float $score + */ + public function setScore( $score ) { + $this->score = $score; + } + + /** + * Suggestion URL, can be the link to the Title or maybe in the + * future a link to the search results for this search suggestion. + * @return string Suggestion URL + */ + public function getURL() { + return $this->url; + } + + /** + * Set the suggestion URL + * @param string $url + */ + public function setURL( $url ) { + $this->url = $url; + } + + /** + * Create suggestion from Title + * @param float $score Suggestions score + * @param Title $title + * @return SearchSuggestion + */ + public static function fromTitle( $score, Title $title ) { + return new self( $score, $title->getPrefixedText(), $title, $title->getArticleID() ); + } + + /** + * Create suggestion from text + * Will also create a title if text if not empty. + * @param float $score Suggestions score + * @param string $text + * @return SearchSuggestion + */ + public static function fromText( $score, $text ) { + $suggestion = new self( $score, $text ); + if ( $text ) { + $suggestion->setSuggestedTitle( Title::makeTitle( 0, $text ) ); + } + return $suggestion; + } + +} diff --git a/includes/search/SearchSuggestionSet.php b/includes/search/SearchSuggestionSet.php new file mode 100644 index 0000000000..a1f9a04c9e --- /dev/null +++ b/includes/search/SearchSuggestionSet.php @@ -0,0 +1,213 @@ +getSuggestedTitleID(); + if ( $pageID && empty( $this->pageMap[$pageID] ) ) { + $this->pageMap[$pageID] = true; + } + $this->suggestions[] = $suggestion; + } + } + + /** + * Get the list of suggestions. + * @return SearchSuggestion[] + */ + public function getSuggestions() { + return $this->suggestions; + } + + /** + * Call array_map on the suggestions array + * @param callback $callback + * @return array + */ + public function map( $callback ) { + return array_map( $callback, $this->suggestions ); + } + + /** + * Add a new suggestion at the end. + * If the score of the new suggestion is greater than the worst one, + * the new suggestion score will be updated (worst - 1). + * + * @param SearchSuggestion $suggestion + */ + public function append( SearchSuggestion $suggestion ) { + $pageID = $suggestion->getSuggestedTitleID(); + if ( $pageID && isset( $this->pageMap[$pageID] ) ) { + return; + } + if ( $this->getSize() > 0 && $suggestion->getScore() >= $this->getWorstScore() ) { + $suggestion->setScore( $this->getWorstScore() - 1 ); + } + $this->suggestions[] = $suggestion; + if ( $pageID ) { + $this->pageMap[$pageID] = true; + } + } + + /** + * Add suggestion set to the end of the current one. + * @param SearchSuggestionSet $set + */ + public function appendAll( SearchSuggestionSet $set ) { + foreach ( $set->getSuggestions() as $sugg ) { + $this->append( $sugg ); + } + } + + /** + * Move the suggestion at index $key to the first position + */ + public function rescore( $key ) { + $removed = array_splice( $this->suggestions, $key, 1 ); + unset( $this->pageMap[$removed[0]->getSuggestedTitleID()] ); + $this->prepend( $removed[0] ); + } + + /** + * Add a new suggestion at the top. If the new suggestion score + * is lower than the best one its score will be updated (best + 1) + * @param SearchSuggestion $suggestion + */ + public function prepend( SearchSuggestion $suggestion ) { + $pageID = $suggestion->getSuggestedTitleID(); + if ( $pageID && isset( $this->pageMap[$pageID] ) ) { + return; + } + if ( $this->getSize() > 0 && $suggestion->getScore() <= $this->getBestScore() ) { + $suggestion->setScore( $this->getBestScore() + 1 ); + } + array_unshift( $this->suggestions, $suggestion ); + if ( $pageID ) { + $this->pageMap[$pageID] = true; + } + } + + /** + * @return float the best score in this suggestion set + */ + public function getBestScore() { + if ( empty( $this->suggestions ) ) { + return 0; + } + return $this->suggestions[0]->getScore(); + } + + /** + * @return float the worst score in this set + */ + public function getWorstScore() { + if ( empty( $this->suggestions ) ) { + return 0; + } + return end( $this->suggestions )->getScore(); + } + + /** + * @return int the number of suggestion in this set + */ + public function getSize() { + return count( $this->suggestions ); + } + + /** + * Remove any extra elements in the suggestions set + * @param int $limit the max size of this set. + */ + public function shrink( $limit ) { + if ( count( $this->suggestions ) > $limit ) { + $this->suggestions = array_slice( $this->suggestions, 0, $limit ); + } + } + + /** + * Builds a new set of suggestion based on a title array. + * Useful when using a backend that supports only Titles. + * + * NOTE: Suggestion scores will be generated. + * + * @param Title[] $titles + * @return SearchSuggestionSet + */ + public static function fromTitles( array $titles ) { + $score = count( $titles ); + $suggestions = array_map( function( $title ) use ( &$score ) { + return SearchSuggestion::fromTitle( $score--, $title ); + }, $titles ); + return new SearchSuggestionSet( $suggestions ); + } + + /** + * Builds a new set of suggestion based on a string array. + * + * NOTE: Suggestion scores will be generated. + * + * @param string[] $titles + * @return SearchSuggestionSet + */ + public static function fromStrings( array $titles ) { + $score = count( $titles ); + $suggestions = array_map( function( $title ) use ( &$score ) { + return SearchSuggestion::fromText( $score--, $title ); + }, $titles ); + return new SearchSuggestionSet( $suggestions ); + } + + + /** + * @return SearchSuggestionSet an empty suggestion set + */ + public static function emptySuggestionSet() { + return new SearchSuggestionSet( array() ); + } +} diff --git a/includes/specialpage/SpecialPage.php b/includes/specialpage/SpecialPage.php index 04171466e6..6158df27a2 100644 --- a/includes/specialpage/SpecialPage.php +++ b/includes/specialpage/SpecialPage.php @@ -328,6 +328,29 @@ class SpecialPage { return array(); } + /** + * Perform a regular substring search for prefixSearchSubpages + * @param string $search Prefix to search for + * @param int $limit Maximum number of results to return (usually 10) + * @param int $offset Number of results to skip (usually 0) + * @return string[] Matching subpages + */ + protected function prefixSearchString( $search, $limit, $offset ) { + $title = Title::newFromText( $search ); + if ( !$title || !$title->canExist() ) { + // No prefix suggestion in special and media namespace + return array(); + } + + $search = SearchEngine::create(); + $search->setLimitOffset( $limit, $offset ); + $search->setNamespaces( array() ); + $result = $search->defaultPrefixSearch( $search ); + return array_map( function( Title $t ) { + return $t->getPrefixedText(); + }, $result ); + } + /** * Helper function for implementations of prefixSearchSubpages() that * filter the values in memory (as opposed to making a query). diff --git a/includes/specials/SpecialAllPages.php b/includes/specials/SpecialAllPages.php index 9e75522289..0bf93be794 100644 --- a/includes/specials/SpecialAllPages.php +++ b/includes/specials/SpecialAllPages.php @@ -365,15 +365,7 @@ class SpecialAllPages extends IncludableSpecialPage { * @return string[] Matching subpages */ public function prefixSearchSubpages( $search, $limit, $offset ) { - $title = Title::newFromText( $search ); - if ( !$title || !$title->canExist() ) { - // No prefix suggestion in special and media namespace - return array(); - } - // Autocomplete subpage the same as a normal search - $prefixSearcher = new StringPrefixSearch; - $result = $prefixSearcher->search( $search, $limit, array(), $offset ); - return $result; + return $this->prefixSearchString( $search, $limit, $offset ); } protected function getGroupName() { diff --git a/includes/specials/SpecialChangeContentModel.php b/includes/specials/SpecialChangeContentModel.php index a9a7f97faf..1f32e3f808 100644 --- a/includes/specials/SpecialChangeContentModel.php +++ b/includes/specials/SpecialChangeContentModel.php @@ -234,15 +234,7 @@ class SpecialChangeContentModel extends FormSpecialPage { * @return string[] Matching subpages */ public function prefixSearchSubpages( $search, $limit, $offset ) { - $title = Title::newFromText( $search ); - if ( !$title || !$title->canExist() ) { - // No prefix suggestion in special and media namespace - return array(); - } - // Autocomplete subpage the same as a normal search - $prefixSearcher = new StringPrefixSearch; - $result = $prefixSearcher->search( $search, $limit, array(), $offset ); - return $result; + return $this->prefixSearchString( $search, $limit, $offset ); } protected function getGroupName() { diff --git a/includes/specials/SpecialFileDuplicateSearch.php b/includes/specials/SpecialFileDuplicateSearch.php index 323903e227..9970dfa971 100644 --- a/includes/specials/SpecialFileDuplicateSearch.php +++ b/includes/specials/SpecialFileDuplicateSearch.php @@ -246,9 +246,11 @@ class FileDuplicateSearchPage extends QueryPage { // No prefix suggestion outside of file namespace return array(); } + $search = SearchEngine::create(); + $search->setLimitOffset( $limit, $offset ); // Autocomplete subpage the same as a normal search, but just for files - $prefixSearcher = new TitlePrefixSearch; - $result = $prefixSearcher->search( $search, $limit, array( NS_FILE ), $offset ); + $search->setNamespaces( array( NS_FILE ) ); + $result = $search->defaultPrefixSearch( $search ); return array_map( function ( Title $t ) { // Remove namespace in search suggestion diff --git a/includes/specials/SpecialMovepage.php b/includes/specials/SpecialMovepage.php index a7e5e02037..339c1d9081 100644 --- a/includes/specials/SpecialMovepage.php +++ b/includes/specials/SpecialMovepage.php @@ -820,15 +820,7 @@ class MovePageForm extends UnlistedSpecialPage { * @return string[] Matching subpages */ public function prefixSearchSubpages( $search, $limit, $offset ) { - $title = Title::newFromText( $search ); - if ( !$title || !$title->canExist() ) { - // No prefix suggestion in special and media namespace - return array(); - } - // Autocomplete subpage the same as a normal search - $prefixSearcher = new StringPrefixSearch; - $result = $prefixSearcher->search( $search, $limit, array(), $offset ); - return $result; + return $this->prefixSearchString( $search, $limit, $offset ); } protected function getGroupName() { diff --git a/includes/specials/SpecialPageLanguage.php b/includes/specials/SpecialPageLanguage.php index 69a9d486b3..38093be417 100644 --- a/includes/specials/SpecialPageLanguage.php +++ b/includes/specials/SpecialPageLanguage.php @@ -214,15 +214,7 @@ class SpecialPageLanguage extends FormSpecialPage { * @return string[] Matching subpages */ public function prefixSearchSubpages( $search, $limit, $offset ) { - $title = Title::newFromText( $search ); - if ( !$title || !$title->canExist() ) { - // No prefix suggestion in special and media namespace - return array(); - } - // Autocomplete subpage the same as a normal search - $prefixSearcher = new StringPrefixSearch; - $result = $prefixSearcher->search( $search, $limit, array(), $offset ); - return $result; + return $this->prefixSearchString( $search, $limit, $offset ); } protected function getGroupName() { diff --git a/includes/specials/SpecialPrefixindex.php b/includes/specials/SpecialPrefixindex.php index a6c0423b27..6401063a53 100644 --- a/includes/specials/SpecialPrefixindex.php +++ b/includes/specials/SpecialPrefixindex.php @@ -303,15 +303,7 @@ class SpecialPrefixindex extends SpecialAllPages { * @return string[] Matching subpages */ public function prefixSearchSubpages( $search, $limit, $offset ) { - $title = Title::newFromText( $search ); - if ( !$title || !$title->canExist() ) { - // No prefix suggestion in special and media namespace - return array(); - } - // Autocomplete subpage the same as a normal search - $prefixSearcher = new StringPrefixSearch; - $result = $prefixSearcher->search( $search, $limit, array(), $offset ); - return $result; + return $this->prefixSearchString( $search, $limit, $offset ); } protected function getGroupName() { diff --git a/includes/specials/SpecialRecentchangeslinked.php b/includes/specials/SpecialRecentchangeslinked.php index 8db8f24623..dc210db110 100644 --- a/includes/specials/SpecialRecentchangeslinked.php +++ b/includes/specials/SpecialRecentchangeslinked.php @@ -273,14 +273,6 @@ class SpecialRecentChangesLinked extends SpecialRecentChanges { * @return string[] Matching subpages */ public function prefixSearchSubpages( $search, $limit, $offset ) { - $title = Title::newFromText( $search ); - if ( !$title || !$title->canExist() ) { - // No prefix suggestion in special and media namespace - return array(); - } - // Autocomplete subpage the same as a normal search - $prefixSearcher = new StringPrefixSearch; - $result = $prefixSearcher->search( $search, $limit, array(), $offset ); - return $result; + return $this->prefixSearchString( $search, $limit, $offset ); } } diff --git a/includes/specials/SpecialUndelete.php b/includes/specials/SpecialUndelete.php index f99a52dc76..078f0325c8 100644 --- a/includes/specials/SpecialUndelete.php +++ b/includes/specials/SpecialUndelete.php @@ -1709,15 +1709,7 @@ class SpecialUndelete extends SpecialPage { * @return string[] Matching subpages */ public function prefixSearchSubpages( $search, $limit, $offset ) { - $title = Title::newFromText( $search ); - if ( !$title || !$title->canExist() ) { - // No prefix suggestion in special and media namespace - return array(); - } - // Autocomplete subpage the same as a normal search - $prefixSearcher = new StringPrefixSearch; - $result = $prefixSearcher->search( $search, $limit, array(), $offset ); - return $result; + return $this->prefixSearchString( $search, $limit, $offset ); } protected function getGroupName() { diff --git a/includes/specials/SpecialWhatlinkshere.php b/includes/specials/SpecialWhatlinkshere.php index 47fd972e4c..45ef9a2d4e 100644 --- a/includes/specials/SpecialWhatlinkshere.php +++ b/includes/specials/SpecialWhatlinkshere.php @@ -548,15 +548,7 @@ class SpecialWhatLinksHere extends IncludableSpecialPage { * @return string[] Matching subpages */ public function prefixSearchSubpages( $search, $limit, $offset ) { - $title = Title::newFromText( $search ); - if ( !$title || !$title->canExist() ) { - // No prefix suggestion in special and media namespace - return array(); - } - // Autocomplete subpage the same as a normal search - $prefixSearcher = new StringPrefixSearch; - $result = $prefixSearcher->search( $search, $limit, array(), $offset ); - return $result; + return $this->prefixSearchString( $search, $limit, $offset ); } protected function getGroupName() { diff --git a/tests/phpunit/includes/search/SearchEnginePrefixTest.php b/tests/phpunit/includes/search/SearchEnginePrefixTest.php new file mode 100644 index 0000000000..2664fa6586 --- /dev/null +++ b/tests/phpunit/includes/search/SearchEnginePrefixTest.php @@ -0,0 +1,334 @@ +isWikitextNS( NS_MAIN ) ) { + // tests are skipped if NS_MAIN is not wikitext + return; + } + + $this->insertPage( 'Sandbox' ); + $this->insertPage( 'Bar' ); + $this->insertPage( 'Example' ); + $this->insertPage( 'Example Bar' ); + $this->insertPage( 'Example Foo' ); + $this->insertPage( 'Example Foo/Bar' ); + $this->insertPage( 'Example/Baz' ); + $this->insertPage( 'Redirect test', '#REDIRECT [[Redirect Test]]' ); + $this->insertPage( 'Redirect Test' ); + $this->insertPage( 'Redirect Test Worse Result' ); + $this->insertPage( 'Redirect test2', '#REDIRECT [[Redirect Test2]]' ); + $this->insertPage( 'Redirect TEST2', '#REDIRECT [[Redirect Test2]]' ); + $this->insertPage( 'Redirect Test2' ); + $this->insertPage( 'Redirect Test2 Worse Result' ); + + $this->insertPage( 'Talk:Sandbox' ); + $this->insertPage( 'Talk:Example' ); + + $this->insertPage( 'User:Example' ); + } + + protected function setUp() { + parent::setUp(); + + if ( !$this->isWikitextNS( NS_MAIN ) ) { + $this->markTestSkipped( 'Main namespace does not support wikitext.' ); + } + + // Avoid special pages from extensions interferring with the tests + $this->setMwGlobals( 'wgSpecialPages', array() ); + $this->search = SearchEngine::create(); + $this->search->setNamespaces( array() ); + } + + protected function searchProvision( Array $results = null ) { + if ( $results === null ) { + $this->setMwGlobals( 'wgHooks', array() ); + } else { + $this->setMwGlobals( 'wgHooks', array( + 'PrefixSearchBackend' => array( + function ( $namespaces, $search, $limit, &$srchres ) use ( $results ) { + $srchres = $results; + return false; + } + ), + ) ); + } + } + + public static function provideSearch() { + return array( + array( array( + 'Empty string', + 'query' => '', + 'results' => array(), + ) ), + array( array( + 'Main namespace with title prefix', + 'query' => 'Ex', + 'results' => array( + 'Example', + 'Example/Baz', + 'Example Bar', + ), + // Third result when testing offset + 'offsetresult' => array( + 'Example Foo', + ), + ) ), + array( array( + 'Talk namespace prefix', + 'query' => 'Talk:', + 'results' => array( + 'Talk:Example', + 'Talk:Sandbox', + ), + ) ), + array( array( + 'User namespace prefix', + 'query' => 'User:', + 'results' => array( + 'User:Example', + ), + ) ), + array( array( + 'Special namespace prefix', + 'query' => 'Special:', + 'results' => array( + 'Special:ActiveUsers', + 'Special:AllMessages', + 'Special:AllMyFiles', + ), + // Third result when testing offset + 'offsetresult' => array( + 'Special:AllMyUploads', + ), + ) ), + array( array( + 'Special namespace with prefix', + 'query' => 'Special:Un', + 'results' => array( + 'Special:Unblock', + 'Special:UncategorizedCategories', + 'Special:UncategorizedFiles', + ), + // Third result when testing offset + 'offsetresult' => array( + 'Special:UncategorizedImages', + ), + ) ), + array( array( + 'Special page name', + 'query' => 'Special:EditWatchlist', + 'results' => array( + 'Special:EditWatchlist', + ), + ) ), + array( array( + 'Special page subpages', + 'query' => 'Special:EditWatchlist/', + 'results' => array( + 'Special:EditWatchlist/clear', + 'Special:EditWatchlist/raw', + ), + ) ), + array( array( + 'Special page subpages with prefix', + 'query' => 'Special:EditWatchlist/cl', + 'results' => array( + 'Special:EditWatchlist/clear', + ), + ) ), + ); + } + + /** + * @dataProvider provideSearch + * @covers SearchEngine::defaultPrefixSearch + */ + public function testSearch( Array $case ) { + $this->search->setLimitOffset( 3 ); + $results = $this->search->defaultPrefixSearch( $case['query'] ); + $results = array_map( function( Title $t ) { + return $t->getPrefixedText(); + }, $results ); + $this->assertEquals( + $case['results'], + $results, + $case[0] + ); + } + + /** + * @dataProvider provideSearch + * @covers SearchEngine::defaultPrefixSearch + */ + public function testSearchWithOffset( Array $case ) { + $this->search->setLimitOffset( 3, 1 ); + $results = $this->search->defaultPrefixSearch( $case['query'] ); + $results = array_map( function( Title $t ) { + return $t->getPrefixedText(); + }, $results ); + + // We don't expect the first result when offsetting + array_shift( $case['results'] ); + // And sometimes we expect a different last result + $expected = isset( $case['offsetresult'] ) ? + array_merge( $case['results'], $case['offsetresult'] ) : + $case['results']; + + $this->assertEquals( + $expected, + $results, + $case[0] + ); + } + + public static function provideSearchBackend() { + return array( + array( array( + 'Simple case', + 'provision' => array( + 'Bar', + 'Barcelona', + 'Barbara', + ), + 'query' => 'Bar', + 'results' => array( + 'Bar', + 'Barcelona', + 'Barbara', + ), + ) ), + array( array( + 'Exact match not on top (bug 70958)', + 'provision' => array( + 'Barcelona', + 'Bar', + 'Barbara', + ), + 'query' => 'Bar', + 'results' => array( + 'Bar', + 'Barcelona', + 'Barbara', + ), + ) ), + array( array( + 'Exact match missing (bug 70958)', + 'provision' => array( + 'Barcelona', + 'Barbara', + 'Bart', + ), + 'query' => 'Bar', + 'results' => array( + 'Bar', + 'Barcelona', + 'Barbara', + ), + ) ), + array( array( + 'Exact match missing and not existing', + 'provision' => array( + 'Exile', + 'Exist', + 'External', + ), + 'query' => 'Ex', + 'results' => array( + 'Exile', + 'Exist', + 'External', + ), + ) ), + array( array( + "Exact match shouldn't override already found match if " . + "exact is redirect and found isn't", + 'provision' => array( + // Target of the exact match is low in the list + 'Redirect Test Worse Result', + 'Redirect Test', + ), + 'query' => 'redirect test', + 'results' => array( + // Redirect target is pulled up and exact match isn't added + 'Redirect Test', + 'Redirect Test Worse Result', + ), + ) ), + array( array( + "Exact match shouldn't override already found match if " . + "both exact match and found match are redirect", + 'provision' => array( + // Another redirect to the same target as the exact match + // is low in the list + 'Redirect Test2 Worse Result', + 'Redirect test2', + ), + 'query' => 'redirect TEST2', + 'results' => array( + // Found redirect is pulled to the top and exact match isn't + // added + 'Redirect test2', + 'Redirect Test2 Worse Result', + ), + ) ), + array( array( + "Exact match should override any already found matches that " . + "are redirects to it", + 'provision' => array( + // Another redirect to the same target as the exact match + // is low in the list + 'Redirect Test Worse Result', + 'Redirect test', + ), + 'query' => 'Redirect Test', + 'results' => array( + // Found redirect is pulled to the top and exact match isn't + // added + 'Redirect Test', + 'Redirect Test Worse Result', + 'Redirect test', + ), + ) ), + ); + } + + /** + * @dataProvider provideSearchBackend + * @covers PrefixSearch::searchBackend + */ + public function testSearchBackend( Array $case ) { + $search = $stub = $this->getMockBuilder( 'SearchEngine' ) + ->setMethods( array( 'completionSearchBackend' ) )->getMock(); + + $return = SearchSuggestionSet::fromStrings( $case['provision'] ); + + $search->expects( $this->any() ) + ->method( 'completionSearchBackend' ) + ->will( $this->returnValue( $return ) ); + + $search->setLimitOffset( 3 ); + $results = $search->completionSearch( $case['query'] ); + + $results = $results->map( function( SearchSuggestion $s ) { + return $s->getText(); + } ); + + $this->assertEquals( + $case['results'], + $results, + $case[0] + ); + } +} diff --git a/tests/phpunit/includes/search/SearchSuggestionSetTest.php b/tests/phpunit/includes/search/SearchSuggestionSetTest.php new file mode 100644 index 0000000000..60559fcafc --- /dev/null +++ b/tests/phpunit/includes/search/SearchSuggestionSetTest.php @@ -0,0 +1,104 @@ +assertEquals( 0, $set->getSize() ); + $set->append( new SearchSuggestion( 3 ) ); + $this->assertEquals( 3, $set->getWorstScore() ); + $this->assertEquals( 3, $set->getBestScore() ); + + $suggestion = new SearchSuggestion( 4 ); + $set->append( $suggestion ); + $this->assertEquals( 2, $set->getWorstScore() ); + $this->assertEquals( 3, $set->getBestScore() ); + $this->assertEquals( 2, $suggestion->getScore() ); + + $suggestion = new SearchSuggestion( 2 ); + $set->append( $suggestion ); + $this->assertEquals( 1, $set->getWorstScore() ); + $this->assertEquals( 3, $set->getBestScore() ); + $this->assertEquals( 1, $suggestion->getScore() ); + + $scores = $set->map( function( $s ) { + return $s->getScore(); + } ); + $sorted = $scores; + asort( $sorted ); + $this->assertEquals( $sorted, $scores ); + } + + /** + * Test that adding a new best suggestion will keep proper score + * ordering + */ + public function testInsertBest() { + $set = SearchSuggestionSet::emptySuggestionSet(); + $this->assertEquals( 0, $set->getSize() ); + $set->prepend( new SearchSuggestion( 3 ) ); + $this->assertEquals( 3, $set->getWorstScore() ); + $this->assertEquals( 3, $set->getBestScore() ); + + $suggestion = new SearchSuggestion( 4 ); + $set->prepend( $suggestion ); + $this->assertEquals( 3, $set->getWorstScore() ); + $this->assertEquals( 4, $set->getBestScore() ); + $this->assertEquals( 4, $suggestion->getScore() ); + + $suggestion = new SearchSuggestion( 0 ); + $set->prepend( $suggestion ); + $this->assertEquals( 3, $set->getWorstScore() ); + $this->assertEquals( 5, $set->getBestScore() ); + $this->assertEquals( 5, $suggestion->getScore() ); + + $suggestion = new SearchSuggestion( 2 ); + $set->prepend( $suggestion ); + $this->assertEquals( 3, $set->getWorstScore() ); + $this->assertEquals( 6, $set->getBestScore() ); + $this->assertEquals( 6, $suggestion->getScore() ); + + $scores = $set->map( function( $s ) { + return $s->getScore(); + } ); + $sorted = $scores; + asort( $sorted ); + $this->assertEquals( $sorted, $scores ); + } + + public function testShrink() { + $set = SearchSuggestionSet::emptySuggestionSet(); + for ( $i = 0; $i < 100; $i++ ) { + $set->append( new SearchSuggestion( 0 ) ); + } + $set->shrink( 10 ); + $this->assertEquals( 10, $set->getSize() ); + + $set->shrink( 0 ); + $this->assertEquals( 0, $set->getSize() ); + } + + // TODO: test for fromTitles +} -- 2.20.1